52 — 为场景添加细节
简介
有了烘焙纹理,我们的场景看起来很棒,但我们还没有真正发挥出 WebGL 的全部潜力。让我们添加更多细节,让场景更有生气。
首先,我们要添加一些四处漂浮的萤火虫。然后,我们会在传送门内部制作一段很酷的动画。但首先,我们需要改变那个黑色的背景。
设置
代码与上一课结束时完全一致。
我们的场景已经导入到 Three.js 中,可以用 OrbitControls 环绕旋转,并且我们的 lil-gui 实例也已经准备好了。
背景颜色
对于背景,我们不打算做得太花哨。我们只是把颜色换成与场景更匹配一点的颜色。为了找到完美的颜色,我们要给 lil-gui 添加一个 tweak。
在创建 lil-gui 实例之前,添加一个 debugObject:
// Debug
const debugObject = {}
const gui = new GUI({
width: 400
}) 和前面几课一样,这个对象的作用只是把颜色作为一个属性存储起来,以便我们把它作为 tweak 添加到 lil-gui。
在实例化 renderer 之后,向 debugObject 对象添加一个 clearColor 属性:
// Clear color
debugObject.clearColor = '#ff0000' 然后用 gui.addColor(...) 把它添加到 lil-gui 的 tweak 中:
gui.addColor(debugObject, 'clearColor')
你应该能看到颜色 tweak,但我们还没有使用它。
使用该值通过 renderer.setClearColor(...) 来更改背景颜色:
debugObject.clearColor = '#ff0000'
renderer.setClearColor(debugObject.clearColor)
gui.addColor(debugObject, 'clearColor')
遗憾的是,修改 tweak 并不会改变背景的颜色,因为我们只是改变了 clearColor 属性。
当 tweak 变化时,我们需要再次调用 renderer.setClearColor(...):
gui
.addColor(debugObject, 'clearColor')
.onChange(() =>
{
renderer.setClearColor(debugObject.clearColor)
})
现在你可以找出与你的场景最匹配的颜色了:
debugObject.clearColor = '#201919'
萤火虫
为了创建萤火虫,我们要使用带有自定义几何体的粒子。
基础
首先创建一个空的 BufferGeometry:
/**
* Fireflies
*/
// Geometry
const firefliesGeometry = new THREE.BufferGeometry() 创建一个名为 position 的 BufferAttribute 来设置每个粒子的位置:
const firefliesGeometry = new THREE.BufferGeometry()
const firefliesCount = 30
const positionArray = new Float32Array(firefliesCount * 3)
for(let i = 0; i < firefliesCount; i++)
{
positionArray[i * 3 + 0] = Math.random() * 4
positionArray[i * 3 + 1] = Math.random() * 4
positionArray[i * 3 + 2] = Math.random() * 4
}
firefliesGeometry.setAttribute('position', new THREE.BufferAttribute(positionArray, 3)) 萤火虫的位置是随机的,但等我们能看到萤火虫之后,会再改进它们的位置。
为了能看到萤火虫,我们要使用一个 PointsMaterial,但稍后我们会把它换成一个自定义的 ShaderMaterial:
创建一个 PointsMaterial,size 为 0.1,并把 sizeAttenuation 设为 true:
// Material
const firefliesMaterial = new THREE.PointsMaterial({ size: 0.1, sizeAttenuation: true }) 最后,使用 firefliesGeometry 和 firefliesMaterial 创建 Points 实例,并把它添加到场景中:
// Points
const fireflies = new THREE.Points(firefliesGeometry, firefliesMaterial)
scene.add(fireflies)
你应该会看到一些很大的方形粒子。
让我们更改位置,使其适合场景:
for(let i = 0; i < firefliesCount; i++)
{
positionArray[i * 3 + 0] = (Math.random() - 0.5) * 4
positionArray[i * 3 + 1] = Math.random() * 1.5
positionArray[i * 3 + 2] = (Math.random() - 0.5) * 4
}
粒子在 x 和 z 轴上不应该离得太远,并且在 y 轴上应保持在地面之上。
自定义着色器材质
现在我们可以把材质换成 ShaderMaterial,以便让它们动起来,同时也绘制出比那些丑陋方形更好看的东西。
- 在
/src/文件夹中,创建一个shaders/文件夹。 - 在那个
shaders/文件夹中,创建一个fireflies/文件夹。 - 在那个
fireflies/文件夹中(稍等),创建一个vertex.glsl和一个fragment.glsl文件。
你可以试着自己编写这些 shader。
以下是 /src/shaders/fireflies/vertex.glsl 的代码:
void main()
{
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPosition = projectionMatrix * viewPosition;
gl_Position = projectionPosition;
gl_PointSize = 40.0;
} 这段代码没有做任何特别的事情。通过应用各种矩阵,顶点被放置到它应该在的位置。我们还添加了 gl_PointSize 来控制粒子的大小。
以下是 /src/shaders/fireflies/fragment.glsl 的代码:
void main()
{
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
} 与 vertex shader 一样,这段代码没有做任何特别的事情,粒子应该显示为红色。
现在我们可以把这些 shader 导入到脚本中:
import firefliesVertexShader from './shaders/fireflies/vertex.glsl'
import firefliesFragmentShader from './shaders/fireflies/fragment.glsl' 加载 .glsl 文件的规则已经添加到 Vite 配置中。firefliesVertexShader 和 firefliesFragmentShader 应该包含我们编写的两个 shader。
把 PointsMaterial 替换为一个带有 vertexShader 和 fragmentShader 属性的 ShaderMaterial:
const firefliesMaterial = new THREE.ShaderMaterial({
vertexShader: firefliesVertexShader,
fragmentShader: firefliesFragmentShader
})
你应该会得到红色方块作为萤火虫。
点的大小
那些方块在你的屏幕上可能看起来更大,这是由像素比(pixel ratio)造成的。在我们的 vertex shader 中,我们写了 gl_PointSize = 40.0;,这意味着每个粒子在渲染中会占据 40 像素的宽度和高度,但如果你使用的是高像素比的屏幕,40 像素会显得更小,因为同样的空间里有更多像素。
为了解决这个问题,我们可以把像素比作为一个 uniform 传进去:
// Material
const firefliesMaterial = new THREE.ShaderMaterial({
uniforms:
{
uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) }
},
vertexShader: firefliesVertexShader,
fragmentShader: firefliesFragmentShader
}) 我们没有使用 renderer.getPixelRatio(),因为 renderer 是在后面才声明的。这样做会导致错误。但如果你愿意,可以把整个萤火虫代码移到 renderer 之后。
现在我们可以在 vertex shader 中获取 uPixelRatio uniform,并用它乘以我们的 gl_PointSize:
uniform float uPixelRatio;
void main()
{
// ...
gl_PointSize = 40.0 * uPixelRatio;
}
现在无论屏幕的像素比如何,你都应该看到相同的粒子大小。
如果用户把窗口从一个屏幕移到另一个像素比不同的屏幕上,可能会出现问题。幸运的是,大多数情况下,用户这样做时也会调整窗口大小,让它更合适。因此,我们只需在 resize 回调函数中更新 uniform 即可:
window.addEventListener('resize', () =>
{
// ...
// Update fireflies
firefliesMaterial.uniforms.uPixelRatio.value = Math.min(window.devicePixelRatio, 2)
}) 我们的粒子还有另一个问题:无论距离远近,方块的大小都一样。我们需要启用尺寸衰减(size attenuation),可以用以下代码来实现:
// ...
void main()
{
// ...
gl_PointSize = 40.0 * uPixelRatio;
gl_PointSize *= (1.0 / - viewPosition.z);
}
现在粒子的大小应该取决于到摄像机的距离。
最后,我们可以创建一个 uSize uniform 来控制粒子的大小:
const firefliesMaterial = new THREE.ShaderMaterial({
uniforms:
{
uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) },
uSize: { value: 100 }
},
// ...
}) 在 shader 中使用它:
// ...
uniform float uSize;
void main()
{
// ...
gl_PointSize = uSize * uPixelRatio;
// ...
} 并添加一个 tweak 来控制它:
gui.add(firefliesMaterial.uniforms.uSize, 'value').min(0).max(500).step(1).name('firefliesSize')
萤火虫图案
是时候把那些红色方块变成更好看的萤火虫了。我们要创建一些闪亮的点来表示它们。
我们本可以使用纹理,而且那样性能更好。但为了这节课的目的,也为了对图案有更多控制,我们要在 fragment shader 中绘制它。
把粒子大小提高到大约 200。首先用 gl_PointCoord 显示点的 UV 坐标:
void main()
{
gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
}
我们要用这些坐标在每个中心绘制一个闪亮的点。
首先,我们知道需要处理包含到坐标中心距离的那个值。
创建一个 distanceToCenter 变量,并计算中心(vec2(0.5))与 gl_PointCoord 之间的距离:
void main()
{
float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
} 把这个 distanceToCenter 变量用作 gl_FragColor 的 alpha:
void main()
{
float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
gl_FragColor = vec4(1.0, 1.0, 1.0, distanceToCenter);
} 粒子全是白色的,尽管 alpha 本应发生变化。这是因为我们忘了把材质的 transparent 属性设为 true:
const firefliesMaterial = new THREE.ShaderMaterial({
transparent: true,
// ...
})
现在我们可以通过 alpha 看到我们的图案了。
接下来,我们尝试创建一个闪亮的图案。为此,我们可以从一个非常小的值开始,然后把它除以 distanceToCenter。
创建一个 strength 变量,用 0.05 除以 distanceToCenter,并把它传给 gl_FragColor:
void main()
{
float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
float strength = 0.05 / distanceToCenter;
gl_FragColor = vec4(1.0, 1.0, 1.0, strength);
}
它们看起来不错,但遗憾的是,我们能看到边缘。这是因为这个公式随着距离会变得非常低,但永远不会达到 0:
为了解决这个问题,我们可以减去一个较小的值,但要足够大,以确保结果在到达边缘之前降到 0.0 以下:
void main()
{
float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
float strength = 0.05 / distanceToCenter - 0.1;
gl_FragColor = vec4(1.0, 1.0, 1.0, strength);
}
现在萤火虫看起来像一个个小小的光点。
缩放随机性
在创建有机的东西时,加入随机性总是好的。到目前为止,所有萤火虫的大小都一样。
我们要给每一只发送不同的缩放倍数。
创建一个 aScale 属性,并用 Math.random() 填充随机值。每个顶点只需要 1 个值,所以要确保 Float32Array 的长度正确,并把 BufferAttribute 的第二个参数设为 1:
const scaleArray = new Float32Array(firefliesCount)
for(let i = 0; i < firefliesCount; i++)
{
// ...
scaleArray[i] = Math.random()
}
// ...
firefliesGeometry.setAttribute('aScale', new THREE.BufferAttribute(scaleArray, 1)) 我们可以在 vertex shader 中获取该属性,并在 gl_PointSize 公式中使用它:
// ...
attribute float aScale;
void main()
{
// ...
gl_PointSize = uSize * aScale * uPixelRatio;
gl_PointSize *= (1.0 / - viewPosition.z);
}
现在萤火虫有了不同的大小。
混合
为了让它们更加闪亮、看起来像由光构成的光点,把材质的 blending 属性设为 THREE.AdditiveBlending:
const firefliesMaterial = new THREE.ShaderMaterial({
blending: THREE.AdditiveBlending,
// ...
})
深度写入
在某些特定角度,你会注意到一些裁剪问题:一个粒子似乎遮住了它后面的粒子。即使 alpha 本应被设为一个很低的值,这种情况仍然存在。
为了解决这个问题,我们可以通过把材质上的 depthWrite 属性设为 false 来禁用深度写入:
const firefliesMaterial = new THREE.ShaderMaterial({
depthWrite: false,
// ...
})
漂浮动画
最后,我们希望萤火虫上下漂浮,让它们更有生气。
因为我们将在 vertex shader 中做动画,所以需要把时间传给它。
创建一个 uTime uniform,并在 tick 函数中使用 elapsedTime 变量更新它:
const firefliesMaterial = new THREE.ShaderMaterial({
// ...
uniforms:
{
uTime: { value: 0 },
// ...
},
// ...
})
// ...
const clock = new THREE.Clock()
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Update materials
firefliesMaterial.uniforms.uTime.value = elapsedTime
// ...
} 在 vertex shader 中获取 uTime uniform,并用 sin() 更新 modelPosition.y:
uniform float uTime;
// ...
void main()
{
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
modelPosition.y += sin(uTime);
// ...
}
这是个不错的开始,但我们需要加入一些随机性。首先,让它们不同步。
我们本可以发送一个新属性,但取而代之(因为它们只在 y 轴上移动),我们可以用 x 轴来偏移 sin() 中的值:
// ...
void main()
{
// ...
modelPosition.y += sin(uTime + modelPosition.x * 100.0);
// ...
}
动画看起来更随机了,但我们还需要随机化并减小振幅。
我们可以使用 aScale,让最小的萤火虫移动得更少。我们还可以乘以一个小值来减小整体振幅:
// ...
void main()
{
// ...
modelPosition.y += sin(uTime + modelPosition.x * 100.0) * aScale * 0.2;
// ...
}
萤火虫就到这里了,但你可以更进一步,比如给 lil-gui 添加更多 tweak、改进图案,或者用 uniform 发送一个颜色。
传送门
我们场景的最后一块是传送门。我们要绘制一些比这个单一颜色更好看的东西。
自定义着色器材质
我们可以先把传送门的材质换成 ShaderMaterial。
在 /src/shaders/ 文件夹中,创建一个 portal/ 文件夹。
在那个 portal/ 文件夹中,创建一个 vertex.glsl 文件和一个 fragment.glsl 文件。
同样,你可以试着自己编写这些基础的 vertex 和 fragment shader。
以下是 /src/shaders/portal/vertex.glsl 的代码:
void main()
{
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectionPosition = projectionMatrix * viewPosition;
gl_Position = projectionPosition;
} 以下是 /src/shaders/portal/fragment.glsl 的代码:
void main()
{
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
} 我们把顶点放到它们应该在的位置,并把颜色设置为单一的红色。
现在我们可以把这些 shader 导入到脚本中:
import portalVertexShader from './shaders/portal/vertex.glsl'
import portalFragmentShader from './shaders/portal/fragment.glsl' 把 MeshBasicMaterial 替换为一个带有 vertexShader 和 fragmentShader 属性的 ShaderMaterial:
const portalLightMaterial = new THREE.ShaderMaterial({
vertexShader: portalVertexShader,
fragmentShader: portalFragmentShader
})
你应该会得到一扇红色的传送门,它与场景的其余部分不搭,但我们会解决这个问题。
就像在 Shader Patterns 那一课中一样,我们要使用 UV 坐标在传送门上绘制。
使用一个 vUv varying,把 UV 坐标从 vertex shader 传递到 fragment shader。
vertex shader:
varying vec2 vUv;
void main()
{
// ...
vUv = uv;
} fragment shader:
varying vec2 vUv;
void main()
{
gl_FragColor = vec4(vUv, 1.0, 1.0);
}
如果你使用的是自己的模型,却没有得到这个渐变,可能是因为你的传送门没有正确地展开 UV。
修正 UV 坐标
为了解决这个问题,打开你的 .blend 文件:
只选择传送门:
进入 Edit Mode,选中所有面,并在 UV Editor 中查看 UV 是如何展开的:
在这里,展开得很好,但你的结果可能不同,甚至可能什么都没有。
不用担心 UV Editor 背景中的烘焙纹理。我们不需要关心它。我们只想展开这个圆盘形的传送门,让它填满整张 UV 图。
为此,在仍处于 UV Editor 且选中所有面的情况下,按 U 并选择 Unwrap。
就这样。我们并不真的在意 UV 的旋转,因为我们要绘制的图案是圆形的。
退出 Edit Mode,选择合并后的对象以及所有发光部分(emission)。然后,把所有内容导出为 portal.glb,放到 /static/ 文件夹中:
刷新你的场景,现在你应该得到相同的渐变:
如果渐变的方向不同,也没关系。我们的图案将是圆形的。
Perlin 噪声
现在到了有趣的部分。只要找到正确的公式,你可以在传送门中绘制任何你想要的东西。
接下来的步骤会直接通向一个令人满意的结果。但显然,这是一个尝试了很多东西、直到最终看起来不错才结束的漫长过程。如果你自己做的时候花了几个小时,不要沮丧。这全靠时间和练习。
我们将从经典的 Perlin 噪声开始。在这个例子中,我们将使用 3D 版本,这样我们就可以用时间对第三个值做动画,从而给噪声带来变化。
把下面这个由 Stefan Gustavson 编写的函数(可在 这个 gist 中找到)添加到你的 src/shaders/portal/fragment.glsl 文件中:
// Classic Perlin 3D Noise
// by Stefan Gustavson
//
vec4 permute(vec4 x){ return mod(((x*34.0)+1.0)*x, 289.0); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
vec3 fade(vec3 t) { return t*t*t*(t*(t*6.0-15.0)+10.0); }
float cnoise(vec3 P)
{
vec3 Pi0 = floor(P); // Integer part for indexing
vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1
Pi0 = mod(Pi0, 289.0);
Pi1 = mod(Pi1, 289.0);
vec3 Pf0 = fract(P); // Fractional part for interpolation
vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
vec4 iy = vec4(Pi0.yy, Pi1.yy);
vec4 iz0 = Pi0.zzzz;
vec4 iz1 = Pi1.zzzz;
vec4 ixy = permute(permute(ix) + iy);
vec4 ixy0 = permute(ixy + iz0);
vec4 ixy1 = permute(ixy + iz1);
vec4 gx0 = ixy0 / 7.0;
vec4 gy0 = fract(floor(gx0) / 7.0) - 0.5;
gx0 = fract(gx0);
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
vec4 sz0 = step(gz0, vec4(0.0));
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
gy0 -= sz0 * (step(0.0, gy0) - 0.5);
vec4 gx1 = ixy1 / 7.0;
vec4 gy1 = fract(floor(gx1) / 7.0) - 0.5;
gx1 = fract(gx1);
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
vec4 sz1 = step(gz1, vec4(0.0));
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
gy1 -= sz1 * (step(0.0, gy1) - 0.5);
vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);
vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
g000 *= norm0.x;
g010 *= norm0.y;
g100 *= norm0.z;
g110 *= norm0.w;
vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
g001 *= norm1.x;
g011 *= norm1.y;
g101 *= norm1.z;
g111 *= norm1.w;
float n000 = dot(g000, Pf0);
float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
float n111 = dot(g111, Pf1);
vec3 fade_xyz = fade(Pf0);
vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
return 2.2 * n_xyz;
} 在这个过程结束时,我们将显示两种颜色的混合,但就目前而言,我们只专注于一个变量。
创建一个 strength 变量,并使用我们刚刚添加的 cnoise 函数。
这个函数需要一个 vec3 作为参数。目前,我们会把 vUv 用作 x 和 y,稍后会把时间变量用于 z:
// ...
void main()
{
float strength = cnoise(vec3(vUv * 5.0, 0.0));
gl_FragColor = vec4(vUv, 1.0, 1.0);
} 如果没有错误,把 strength 传给 gl_FragColor:
// ...
void main()
{
float strength = cnoise(vec3(vUv * 5.0, 0.0));
gl_FragColor = vec4(strength, strength, strength, 1.0);
}
这是个不错的开始,我们可以看到 Perlin 噪声了。
在你的 JavaScript 文件中,创建一个 uTime uniform,并像我们对萤火虫所做的那样,在 tick 函数中更新它:
const portalLightMaterial = new THREE.ShaderMaterial({
uniforms:
{
uTime: { value: 0 }
},
// ...
})
// ...
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Update materials
firefliesMaterial.uniforms.uTime.value = elapsedTime
portalLightMaterial.uniforms.uTime.value = elapsedTime
// ...
}
tick() 在 fragment shader 中获取它,并把它作为我们传给 cnoise 的 vec3 的第三个值:
uniform float uTime;
// ...
void main()
{
// Perlin noise
float strength = cnoise(vec3(vUv * 5.0, uTime));
gl_FragColor = vec4(strength, strength, strength, 1.0);
}
我们已经能感受到一些《星际之门》的氛围了。
这个经典的 Perlin 噪声不错,但通过置换 UV 坐标,我们可以得到更好的效果。我们将基于最初的 UV 坐标创建新的 UV 坐标,但会用另一个 Perlin 噪声来偏移 x 和 y:
// ...
void main()
{
// Displace the UV
vec2 displacedUv = vUv + cnoise(vec3(vUv * 5.0, uTime));
// Perlin noise
float strength = cnoise(vec3(displacedUv * 5.0, uTime));
gl_FragColor = vec4(strength, strength, strength, 1.0);
}
动画很棒,但有点太快了。为了放慢它并获得一些有机的随机性,把 uTime 乘以小的值,让 displacedUv 和 strength 使用不同的值:
// ...
void main()
{
// Displace the UV
vec2 displacedUv = vUv + cnoise(vec3(vUv * 5.0, uTime * 0.1));
// Perlin noise
float strength = cnoise(vec3(displacedUv * 5.0, uTime * 0.2));
gl_FragColor = vec4(strength, strength, strength, 1.0);
}
外部辉光
我们走在正确的道路上,但现在主要关心的是传送门本身与周围砖块之间的衔接处。它们之间的衔接处应该是完全白色的。
我们需要创建一个外部辉光。为此,我们先求出到中心的距离,并把结果显示在 gl_FragColor 中:
// ...
void main()
{
// ...
// Outer glow
float outerGlow = distance(vUv, vec2(0.5));
gl_FragColor = vec4(outerGlow, outerGlow, outerGlow, 1.0);
}
这个渐变正是我们想要的,但我们需要把边缘处的值推高,以确保到达边缘时渐变是完全白色的。为此,不断地对值进行相乘和偏移,直到你满意为止:
// ...
void main()
{
// ...
// Outer glow
float outerGlow = distance(vUv, vec2(0.5)) * 5.0 - 1.4;
gl_FragColor = vec4(outerGlow, outerGlow, outerGlow, 1.0);
}
现在我们可以把 outerGlow 加到 strength 上,并把 strength 传给 gl_FragColor:
// ...
void main()
{
// ...
// Outer glow
float outerGlow = distance(vUv, vec2(0.5)) * 5.0 - 1.4;
strength += outerGlow;
gl_FragColor = vec4(strength, strength, strength, 1.0);
} 我们大致得到了想要的结果,但遗憾的是,边缘并不是完美的白色。这是因为 Perlin 噪声没有被限制在 0.0 到 1.0 之间。这意味着该值实际上可能低于 -1.0 或高于 +1.0。但我们不打算对此做任何处理,因为下一步会解决它。
目前,我们有一个非常平滑的图案,但实际上它太光滑了。我们想给它增加一些锐度。
Step 函数
为此,我们可以对 strength 应用一个 step() 函数。
step() 函数的第一个参数是一个阈值(也叫 edge)。当第二个参数的值高于这个阈值时,你会得到 1.0;如果值低于这个阈值,你会得到 0.0。
你可以为这个阈值尝试不同的值:
// ...
void main()
{
// ...
// Apply cool step
strength = step(- 0.2, strength);
gl_FragColor = vec4(strength, strength, strength, 1.0);
}
我们得到了一个不错的锐利结果,但现在又太锐利了。
我们不是用那个 step() 函数替换 strength,而是把它加到最初的 strength 上。我们还可以把它乘以一个较低的值,稍微减弱 step 的效果:
// ...
void main()
{
// ...
// Apply cool step
strength += step(- 0.2, strength) * 0.8;
gl_FragColor = vec4(strength, strength, strength, 1.0);
}
这就是我们的最终图案。如你所见,边缘是完美的白色,我们在平滑与锐利之间取得了很好的平衡。
颜色
在这个例子中,我们对黑白的结果很满意,因为我们的传送门光最初就是白色的。但是,万一你选择了一个不同的传送门颜色,我们还是加上颜色吧。
在 JavaScript 文件中,添加一个 uColorStart 和一个 uColorEnd uniform,使用红色和蓝色。使用 Color 类:
const portalLightMaterial = new THREE.ShaderMaterial({
uniforms:
{
uTime: { value: 0 },
uColorStart: { value: new THREE.Color(0xff0000) },
uColorEnd: { value: new THREE.Color(0x0000ff) }
},
// ...
}) 在 fragment shader 中获取这些 uniform,用 strength 把它们 mix() 到一个 color 变量中,并把它传给 gl_FragColor:
uniform float uTime;
uniform vec3 uColorStart;
uniform vec3 uColorEnd;
// ...
void main()
{
// ...
// Final color
vec3 color = mix(uColorStart, uColorEnd, strength);
gl_FragColor = vec4(color, 1.0);
}
你应该会得到红色和蓝色之间不错的混合。
让我们把这些颜色添加到我们的 lil-gui。和往常一样,我们把他们作为 debugObject 的属性添加,并监听变化以相应地更新 uniform:
debugObject.portalColorStart = '#ff0000'
debugObject.portalColorEnd = '#0000ff'
gui
.addColor(debugObject, 'portalColorStart')
.onChange(() =>
{
portalLightMaterial.uniforms.uColorStart.value.set(debugObject.portalColorStart)
})
gui
.addColor(debugObject, 'portalColorEnd')
.onChange(() =>
{
portalLightMaterial.uniforms.uColorEnd.value.set(debugObject.portalColorEnd)
})
现在你可以调整颜色了。
但有一个问题。使用某些颜色,比如从深蓝到橙色时,你会注意到边缘会变成黄色,而且我们也会失去之前的锐度:
这是因为 strength 实际上会低于 0.0 或高于 1.0,导致颜色被外推。对于黑白来说问题不大,但换成其他一些颜色就会变得很乱。
为了解决这个问题,我们需要使用 clamp(...) 函数来确保该值被限制在 0.0 和 1.0 之间:
void main()
{
// ...
// Clamp the value
strength = clamp(strength, 0.0, 1.0);
// ...
} 总之,如果你使用的是起始项目中的模型,你大概应该坚持用黑白。
别忘了在 uniform 的初始值中使用 debugObject.portalColorStart 和 debugObject.portalColorEnd:
debugObject.portalColorStart = '#000000'
debugObject.portalColorEnd = '#ffffff'
// ...
const portalLightMaterial = new THREE.ShaderMaterial({
uniforms:
{
uColorStart: { value: new THREE.Color(debugObject.portalColorStart) },
uColorEnd: { value: new THREE.Color(debugObject.portalColorEnd) },
// ...
},
// ...
})
更进一步
我们的传送门场景就到这里了。现在是添加更多 tweak 和更多细节的好时机。
你当然可以尝试使用你学到的所有建模和烘焙流程,从零开始创建你自己的场景。慢慢来,确保拥有合适的几何体和干净的 UV 坐标。别犹豫,在 Twitter 或 Discord 服务器上分享你的成果。